/*
 * $Id: AutoCompleteDocument.java 4051 2011-07-19 20:17:05Z kschaefe $
 *
 * Copyright 2004 Sun Microsystems, Inc., 4150 Network Circle,
 * Santa Clara, California 95054, U.S.A. All rights reserved.
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
 */
package org.jdesktop.swingx.autocomplete;

import static org.jdesktop.swingx.autocomplete.ObjectToStringConverter.DEFAULT_IMPLEMENTATION;

import java.util.Comparator;

import javax.swing.UIManager;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.event.EventListenerList;
import javax.swing.event.UndoableEditEvent;
import javax.swing.event.UndoableEditListener;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.Document;
import javax.swing.text.Element;
import javax.swing.text.PlainDocument;
import javax.swing.text.Position;
import javax.swing.text.Segment;

import org.jdesktop.swingx.util.Contract;

/**
 * A document that can be plugged into any JTextComponent to enable automatic
 * completion. It finds and selects matching items using any implementation of
 * the AbstractAutoCompleteAdaptor.
 */
@SuppressWarnings("nls")
public class AutoCompleteDocument implements Document {
	private class Handler implements DocumentListener, UndoableEditListener {
		private final EventListenerList listenerList = new EventListenerList();

		public void addDocumentListener(DocumentListener listener) {
			listenerList.add(DocumentListener.class, listener);
		}

		public void addUndoableEditListener(UndoableEditListener listener) {
			listenerList.add(UndoableEditListener.class, listener);
		}

		/**
		 * {@inheritDoc}
		 */
		public void removeDocumentListener(DocumentListener listener) {
			listenerList.remove(DocumentListener.class, listener);
		}

		/**
		 * {@inheritDoc}
		 */
		public void removeUndoableEditListener(UndoableEditListener listener) {
			listenerList.remove(UndoableEditListener.class, listener);
		}

		/**
		 * {@inheritDoc}
		 */
		@Override
		public void changedUpdate(DocumentEvent e) {
			e = new DelegatingDocumentEvent(AutoCompleteDocument.this, e);

			// Guaranteed to return a non-null array
			Object[] listeners = listenerList.getListenerList();
			// Process the listeners last to first, notifying
			// those that are interested in this event
			for (int i = listeners.length - 2; i >= 0; i -= 2) {
				if (listeners[i] == DocumentListener.class) {
					// Lazily create the event:
					// if (e == null)
					// e = new ListSelectionEvent(this, firstIndex, lastIndex);
					((DocumentListener) listeners[i + 1]).changedUpdate(e);
				}
			}
		}

		/**
		 * {@inheritDoc}
		 */
		@Override
		public void insertUpdate(DocumentEvent e) {
			e = new DelegatingDocumentEvent(AutoCompleteDocument.this, e);

			// Guaranteed to return a non-null array
			Object[] listeners = listenerList.getListenerList();
			// Process the listeners last to first, notifying
			// those that are interested in this event
			for (int i = listeners.length - 2; i >= 0; i -= 2) {
				if (listeners[i] == DocumentListener.class) {
					// Lazily create the event:
					// if (e == null)
					// e = new ListSelectionEvent(this, firstIndex, lastIndex);
					((DocumentListener) listeners[i + 1]).insertUpdate(e);
				}
			}
		}

		/**
		 * {@inheritDoc}
		 */
		@Override
		public void removeUpdate(DocumentEvent e) {
			e = new DelegatingDocumentEvent(AutoCompleteDocument.this, e);

			// Guaranteed to return a non-null array
			Object[] listeners = listenerList.getListenerList();
			// Process the listeners last to first, notifying
			// those that are interested in this event
			for (int i = listeners.length - 2; i >= 0; i -= 2) {
				if (listeners[i] == DocumentListener.class) {
					// Lazily create the event:
					// if (e == null)
					// e = new ListSelectionEvent(this, firstIndex, lastIndex);
					((DocumentListener) listeners[i + 1]).removeUpdate(e);
				}
			}
		}

		/**
		 * {@inheritDoc}
		 */
		@Override
		public void undoableEditHappened(UndoableEditEvent e) {
			e = new UndoableEditEvent(AutoCompleteDocument.this, e.getEdit());

			// Guaranteed to return a non-null array
			Object[] listeners = listenerList.getListenerList();
			// Process the listeners last to first, notifying
			// those that are interested in this event
			for (int i = listeners.length - 2; i >= 0; i -= 2) {
				if (listeners[i] == UndoableEditListener.class) {
					// Lazily create the event:
					// if (e == null)
					// e = new ListSelectionEvent(this, firstIndex, lastIndex);
					((UndoableEditListener) listeners[i + 1]).undoableEditHappened(e);
				}
			}
		}
	}

	/**
	 * true, if only items from the adaptors's list can be entered false,
	 * otherwise (selected item might not be in the adaptors's list)
	 */
	protected boolean strictMatching;

	protected final Document delegate;

	/**
	 * Flag to indicate if adaptor.setSelectedItem has been called. Subsequent
	 * calls to remove/insertString should be ignored as they are likely have
	 * been caused by the adapted Component that is trying to set the text for
	 * the selected component.
	 */
	boolean selecting = false;

	/**
	 * The adaptor that is used to find and select items.
	 */
	AbstractAutoCompleteAdaptor adaptor;

	ObjectToStringConverter stringConverter;

	private final Handler handler;

	// Note: these comparators do not impose any ordering - e.g. they do not
	// ensure that sgn(compare(x, y)) == -sgn(compare(y, x))
	private static final Comparator<String> EQUALS_IGNORE_CASE = new Comparator<String>() {
		@Override
		public int compare(String o1, String o2) {
			return o1.equalsIgnoreCase(o2) ? 0 : -1;
		}
	};

	private static final Comparator<String> STARTS_WITH_IGNORE_CASE = new Comparator<String>() {
		@Override
		public int compare(String o1, String o2) {
			if (o1.length() < o2.length())
				return -1;
			return o1.regionMatches(true, 0, o2, 0, o2.length()) ? 0 : -1;
		}
	};

	private static final Comparator<String> EQUALS = new Comparator<String>() {
		@Override
		public int compare(String o1, String o2) {
			return o1.equals(o2) ? 0 : -1;
		}
	};

	private static final Comparator<String> STARTS_WITH = new Comparator<String>() {
		@Override
		public int compare(String o1, String o2) {
			return o1.startsWith(o2) ? 0 : -1;
		}
	};

	/**
	 * Creates a new AutoCompleteDocument for the given
	 * AbstractAutoCompleteAdaptor.
	 * 
	 * @param adaptor
	 *            The adaptor that will be used to find and select matching
	 *            items.
	 * @param strictMatching
	 *            true, if only items from the adaptor's list should be allowed
	 *            to be entered
	 * @param stringConverter
	 *            the converter used to transform items to strings
	 * @param delegate
	 *            the {@code Document} delegate backing this document
	 */
	public AutoCompleteDocument(AbstractAutoCompleteAdaptor adaptor, boolean strictMatching,
			ObjectToStringConverter stringConverter, Document delegate) {
		this.adaptor = Contract.asNotNull(adaptor, "adaptor cannot be null");
		this.strictMatching = strictMatching;
		this.stringConverter = stringConverter == null ? DEFAULT_IMPLEMENTATION : stringConverter;
		this.delegate = delegate == null ? createDefaultDocument() : delegate;

		handler = new Handler();
		this.delegate.addDocumentListener(handler);

		// Handle initially selected object
		Object selected = adaptor.getSelectedItem();
		if (selected != null) {
			String itemAsString = this.stringConverter.getPreferredStringForItem(selected);
			setText(itemAsString);
			adaptor.setSelectedItemAsString(itemAsString);
		}
		this.adaptor.markEntireText();
	}

	/**
	 * Creates a new AutoCompleteDocument for the given
	 * AbstractAutoCompleteAdaptor.
	 * 
	 * @param adaptor
	 *            The adaptor that will be used to find and select matching
	 *            items.
	 * @param strictMatching
	 *            true, if only items from the adaptor's list should be allowed
	 *            to be entered
	 * @param stringConverter
	 *            the converter used to transform items to strings
	 */
	public AutoCompleteDocument(AbstractAutoCompleteAdaptor adaptor, boolean strictMatching,
			ObjectToStringConverter stringConverter) {
		this(adaptor, strictMatching, stringConverter, null);
	}

	/**
	 * Creates a new AutoCompleteDocument for the given
	 * AbstractAutoCompleteAdaptor.
	 * 
	 * @param strictMatching
	 *            true, if only items from the adaptor's list should be allowed
	 *            to be entered
	 * @param adaptor
	 *            The adaptor that will be used to find and select matching
	 *            items.
	 */
	public AutoCompleteDocument(AbstractAutoCompleteAdaptor adaptor, boolean strictMatching) {
		this(adaptor, strictMatching, null);
	}

	/**
	 * Creates the default backing document when no delegate is passed to this
	 * document.
	 *
	 * @return the default backing document
	 */
	protected Document createDefaultDocument() {
		return new PlainDocument();
	}

	@Override
	public void remove(int offs, int len) throws BadLocationException {
		// return immediately when selecting an item
		if (selecting)
			return;
		delegate.remove(offs, len);
		if (!strictMatching) {
			setSelectedItem(getText(0, getLength()), getText(0, getLength()));
			adaptor.getTextComponent().setCaretPosition(offs);
		}
	}

	@Override
	public void insertString(int offs, String str, AttributeSet a) throws BadLocationException {
		// return immediately when selecting an item
		if (selecting)
			return;
		// insert the string into the document
		delegate.insertString(offs, str, a);
		// lookup and select a matching item
		LookupResult lookupResult;
		String pattern = getText(0, getLength());

		if (pattern == null || pattern.length() == 0) {
			lookupResult = new LookupResult(null, "");
			setSelectedItem(lookupResult.matchingItem, lookupResult.matchingString);
		} else {
			lookupResult = lookupItem(pattern);
		}

		if (lookupResult.matchingItem != null) {
			setSelectedItem(lookupResult.matchingItem, lookupResult.matchingString);
		} else {
			if (strictMatching) {
				// keep old item selected if there is no match
				lookupResult.matchingItem = adaptor.getSelectedItem();
				lookupResult.matchingString = adaptor.getSelectedItemAsString();
				// imitate no insert (later on offs will be incremented by
				// str.length(): selection won't move forward)
				offs = str == null ? offs : offs - str.length();

				if (str != null && !str.isEmpty()) {
					// provide feedback to the user that his input has been
					// received but can not be accepted
					UIManager.getLookAndFeel().provideErrorFeedback(adaptor.getTextComponent());
				}
			} else {
				// no item matches => use the current input as selected item
				lookupResult.matchingItem = getText(0, getLength());
				lookupResult.matchingString = getText(0, getLength());
				setSelectedItem(lookupResult.matchingItem, lookupResult.matchingString);
			}
		}

		setText(lookupResult.matchingString);

		// select the completed part
		int len = str == null ? 0 : str.length();
		offs = lookupResult.matchingString == null ? 0 : offs + len;
		adaptor.markText(offs);
	}

	/**
	 * Sets the text of this AutoCompleteDocument to the given text.
	 *
	 * @param text
	 *            the text that will be set for this document
	 */
	private void setText(String text) {
		try {
			// remove all text and insert the completed string
			delegate.remove(0, getLength());
			delegate.insertString(0, text, null);
		} catch (BadLocationException e) {
			throw new RuntimeException(e.toString());
		}
	}

	/**
	 * Selects the given item using the AbstractAutoCompleteAdaptor.
	 * 
	 * @param itemAsString
	 *            string representation of the item to be selected
	 * @param item
	 *            the item that is to be selected
	 */
	private void setSelectedItem(Object item, String itemAsString) {
		selecting = true;
		adaptor.setSelectedItem(item);
		adaptor.setSelectedItemAsString(itemAsString);
		selecting = false;
	}

	/**
	 * Searches for an item that matches the given pattern. The
	 * AbstractAutoCompleteAdaptor is used to access the candidate items. The
	 * match is not case-sensitive and will only match at the beginning of each
	 * item's string representation.
	 *
	 * @param pattern
	 *            the pattern that should be matched
	 * @return the first item that matches the pattern or <code>null</code> if
	 *         no item matches
	 */
	private LookupResult lookupItem(String pattern) {
		Object selectedItem = adaptor.getSelectedItem();

		LookupResult lookupResult;

		// first try: case sensitive

		lookupResult = lookupItem(pattern, EQUALS);
		if (lookupResult != null)
			return lookupResult;

		lookupResult = lookupOneItem(selectedItem, pattern, STARTS_WITH);
		if (lookupResult != null)
			return lookupResult;

		lookupResult = lookupItem(pattern, STARTS_WITH);
		if (lookupResult != null)
			return lookupResult;

		// second try: ignore case

		lookupResult = lookupItem(pattern, EQUALS_IGNORE_CASE);
		if (lookupResult != null)
			return lookupResult;

		lookupResult = lookupOneItem(selectedItem, pattern, STARTS_WITH_IGNORE_CASE);
		if (lookupResult != null)
			return lookupResult;

		lookupResult = lookupItem(pattern, STARTS_WITH_IGNORE_CASE);
		if (lookupResult != null)
			return lookupResult;

		// no item starts with the pattern => return null
		return new LookupResult(null, "");
	}

	private LookupResult lookupOneItem(Object item, String pattern, Comparator<String> comparator) {
		String[] possibleStrings = stringConverter.getPossibleStringsForItem(item);
		if (possibleStrings != null) {
			for (int j = 0; j < possibleStrings.length; j++) {
				if (comparator.compare(possibleStrings[j], pattern) == 0) {
					return new LookupResult(item, possibleStrings[j]);
				}
			}
		}
		return null;
	}

	private LookupResult lookupItem(String pattern, Comparator<String> comparator) {
		// iterate over all items and return first match
		for (int i = 0, n = adaptor.getItemCount(); i < n; i++) {
			Object currentItem = adaptor.getItem(i);
			LookupResult result = lookupOneItem(currentItem, pattern, comparator);
			if (result != null)
				return result;
		}
		return null;
	}

	private static class LookupResult {
		Object matchingItem;
		String matchingString;

		public LookupResult(Object matchingItem, String matchingString) {
			this.matchingItem = matchingItem;
			this.matchingString = matchingString;
		}
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public void addDocumentListener(DocumentListener listener) {
		handler.addDocumentListener(listener);
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public void addUndoableEditListener(UndoableEditListener listener) {
		handler.addUndoableEditListener(listener);
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public Position createPosition(int offs) throws BadLocationException {
		return delegate.createPosition(offs);
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public Element getDefaultRootElement() {
		return delegate.getDefaultRootElement();
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public Position getEndPosition() {
		return delegate.getEndPosition();
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public int getLength() {
		return delegate.getLength();
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public Object getProperty(Object key) {
		return delegate.getProperty(key);
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public Element[] getRootElements() {
		return delegate.getRootElements();
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public Position getStartPosition() {
		return delegate.getStartPosition();
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public String getText(int offset, int length) throws BadLocationException {
		return delegate.getText(offset, length);
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public void getText(int offset, int length, Segment txt) throws BadLocationException {
		delegate.getText(offset, length, txt);
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public void putProperty(Object key, Object value) {
		delegate.putProperty(key, value);
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public void removeDocumentListener(DocumentListener listener) {
		handler.removeDocumentListener(listener);
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public void removeUndoableEditListener(UndoableEditListener listener) {
		handler.removeUndoableEditListener(listener);
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public void render(Runnable r) {
		delegate.render(r);
	}

	/**
	 * Returns if only items from the adaptor's list should be allowed to be
	 * entered.
	 * 
	 * @return if only items from the adaptor's list should be allowed to be
	 *         entered
	 */
	public boolean isStrictMatching() {
		return strictMatching;
	}
}